"""
Adapt/extend autotest.client.test.test for Docker test sub-framework
Implement docker subtest base to avoid circular dependences in dockertest core
modules.
"""

# Pylint runs from a different directory, it's fine to import this way
# pylint: disable=W0403

import logging
import os.path
import sys
import traceback
from xceptions import DockerTestFail
from xceptions import DockerTestNAError
from config import CONFIGCUSTOMS, get_as_list
from environment import docker_rpm


def known_failures_file():
    """
    Returns path to a file containing a list of known failures.

    Format of this file is:

          <NVRA>    <subtest name>   <human-friendly description>

    Fields are separated by whitespace; spaces in the third (description)
    field are OK. This lets us column-align the fields for readability.
    Comment lines (starting with '#') and blank lines are ignored.
    """
    return os.path.join(CONFIGCUSTOMS, 'known_failures.txt')


def known_failures():
    """
    Returns a dict containing known test failures. Primary key is
    subtest name (e.g. docker_cli/sub/subsub), value is another dict
    whose key is docker NVRA (e.g. docker-1.12.5-8.el7.x86_64),
    value of that is a string description of the problem (e.g.
    a bz number and comment).
    """
    known_failures_path = known_failures_file()
    known = {}
    try:
        known_failures_fh = open(known_failures_path, 'r')
    except IOError, excpt:
        SubBase.logwarning("Skipping known_failure check: %s" % excpt)
        return known

    for row in known_failures_fh:
        row = row.strip()
        if row and not row.startswith('#'):
            try:
                nvra, subtest, description = row.strip().split(None, 2)
                if subtest not in known:
                    known[subtest] = {}
                known[subtest][nvra] = description
            except ValueError:
                SubBase.logwarning("Bad row in %s: %s"
                                   % (known_failures_path, row))
    return known


class SubBase(object):

    """
    Methods/attributes common to Subtest & SubSubtest classes

    :note: This class is indirectly referenced by the control-file
           so it cannot contain anything dockertest-implementation
           specific.
    """

    #: Configuration section for subclass, auto-generated by ``__init__``.
    config_section = None

    #: Configuration dictionary (read-only for instances)
    config = None

    #: Unique temporary directory for this instance (automatically cleaned up)
    #: **warning**: DO NOT ASSUME DIRECTORY WILL BE EMPTY!!!
    tmpdir = None

    #: Number of additional space/tab characters to prefix when logging
    n_spaces = 16  # date/timestamp length

    #: Number of additional space/tab characters to prefix when logging
    n_tabs = 1     # one-level

    step_log_msgs = {
        "initialize": "initialize()",
        "run_once": "run_once()",
        "postprocess": "postprocess()",
        "cleanup": "cleanup()"
    }

    #: Private dictionary/namespace for storing runtime details, to avoid
    #: using instance attributes.  This protects the instance namespace
    #: from accidental clobbering and provides a common name across all
    #: test code to improve readability, maintenance and debugging.
    #: This attribute and it's value is is otherwise completely ignored
    #: by all ``dockertest`` library code (beyond initialization).
    stuff = None

    #: Path to file indicating which Red Hat release this is
    redhat_release_filepath = "/etc/redhat-release"

    def __init__(self, *args, **dargs):
        super(SubBase, self).__init__(*args, **dargs)
        self.step_log_msgs = self.step_log_msgs.copy()
        # instances can do whatever they like with this, so can sub-classes
        if self.stuff is None:
            self.stuff = {}

    def initialize(self):
        """
        Called every time the test is run.
        """
        self.log_step_msg('initialize')
        # Issue warnings for failed to customize suggested options
        not_customized = self.config.get('__example__', None)
        if not_customized:
            self.logdebug("WARNING: Recommended options not customized:")
            for nco in get_as_list(not_customized):
                self.logdebug("WARNING: %s" % nco)
        msg = "%s configuration:\n" % self.__class__.__name__
        for key, value in self.config.items():
            if key == '__example__' or key.startswith('envcheck'):
                continue
            msg += '\t\t%s = "%s"\n' % (key, value)
        self.logdebug(msg)

    def run_once(self):
        """
        Called once only to exercise subject of sub-subtest
        """
        self.log_step_msg('run_once')

    def postprocess(self):
        """
        Called to process results of subject
        """
        self.log_step_msg('postprocess')

    def cleanup(self):
        """
        Always called, before any exceptions thrown are re-raised.
        """
        self.log_step_msg('cleanup')
        self.overlook_known_failures()

    def overlook_known_failures(self):
        """
        If we've caught a DockerXxxx exception, see if it's a known problem
        with this combination of subtest & docker NVR. If it is, change the
        exception status from FAIL to WARN.
        """
        e_type, e_value, _ = sys.exc_info()

        # In a perfect world we'd use isinstance(e_type, DockerSomething).
        # In this world, there are two problems with that:
        #   1) sometimes a test raises dockertest.xceptions.DockerTestFail,
        #      but sometimes just xceptions.DockerTestFail. I don't know
        #      why. We'd need to check for both.
        #   2) It's not just DockerTestFail: DockerOutputError is common,
        #      and others (eg DockerTestError, DockerCommandError, more)
        #      are possible.
        # A string comparison, although ugly, lets us catch any DockerXxxxx
        if 'Docker' not in str(e_type):
            return

        # Children of autotest TestBaseException have an exit_status field;
        # we only bypass if it's FAIL
        if not hasattr(e_value, 'exit_status'):
            return
        if e_value.exit_status != 'FAIL':
            return

        # Yep, it's a test failure
        # FIXME: is there a way to check if this is a new exception
        #        coming from our module, vs an old one from a previous test?
        if self.is_known_failure():
            self.logwarning("Treating subtest failure as WARN")
            e_value.exit_status = 'WARN'

    def is_known_failure(self, subsubtest=None):
        """
        Do we have a registered known failure in this subtest when running
        on the currently-installed docker? Return True if so.
        Side effect: log warning messages to help human debuggers.
        """
        # e.g. docker_cli/subtest, plus /subsubtest if present
        fullname = self.config_section
        if subsubtest:
            fullname = os.path.join(fullname, subsubtest)
        known = known_failures()
        if fullname not in known:
            return False
        docker_nvr = docker_rpm()
        if docker_nvr in known[fullname]:
            why = known[fullname][docker_nvr]
            self.logwarning("%s: Known failure on %s: %s",
                            fullname, docker_nvr, why)
            return True

        # This exact NVR is not known to fail. What about NV?
        _nv = lambda nvr: nvr[:nvr.rfind('-')]
        docker_nv = _nv(docker_nvr)
        docker_nv_wild = docker_nv + '-*'
        if docker_nv_wild in known[fullname]:
            why = known[fullname][docker_nv_wild]
            self.logwarning("%s expected to fail on all builds of %s: %s",
                            fullname, docker_nv, why)
            return True

        # No known failures for NVR or NV. What about other builds of same NV
        # or a related one? These messages are informational only, intended
        # as hints for a test engineer trying to understand new failures.
        if docker_nv in [_nv(x) for x in known[fullname]]:
            # e.g. docker is 1.12.5-6, we have an exception for 1.12.5->>5<<
            self.logwarning("%s is known to fail in other %s builds",
                            fullname, docker_nv)
        elif docker_nv.count('.') > 1:
            _nv_base = lambda nv_orig: nv_orig[:nv_orig.rfind('.')]
            docker_nv_base = _nv_base(docker_nv)
            if docker_nv_base in [_nv_base(_nv(x)) for x in known[fullname]]:
                # e.g. docker is 1.12.6-1, we have exception for 1.12.>>5<<-*
                self.logwarning("%s is known to fail in other %s.x builds",
                                fullname, docker_nv_base)
        return False

    def log_step_msg(self, stepname):
        """
        Send message stored in ``step_log_msgs`` key ``stepname`` to loginfo
        """
        msg = self.step_log_msgs.get(stepname)
        if msg:
            self.loginfo(msg)

    @staticmethod
    def failif(condition, reason=None, xcept=DockerTestFail):
        """
        Convenience method for subtests to avoid importing ``TestFail``
        exception

        :param condition: Boolean condition, fail test if True.
        :param reason: Helpful text describing why the test failed
        :raise DockerTestFail: If condition evaluates ``True``
        """

        if reason is None:
            reason = "Failed test condition"
        if bool(condition):
            raise xcept(reason)

    @staticmethod
    def failif_ne(actual, expected, reason=None, xcept=DockerTestFail):
        """
        Convenience method for subtests to compare two values and
        fail if they differ. Failure message will include the expected
        and actual values for ease of debugging.

        :param actual: value being tested
        :param expected: value to which we compare.
        :param reason: Helpful text describing why the test failed
        :raise DockerTestFail: If actual != expected
        """

        if actual == expected:
            return
        if reason is None:
            reason = "Failed test condition"

        # By default, quote each value. This is especially helpful when
        # actual or expected is the empty string or a string with spaces.
        # But if both are numeric types the quotes distract, so remove them.
        arg = "'{}'"
        if all(isinstance(x, (int, long, float)) for x in [actual, expected]):
            arg = "{}"
        spec = "{}: expected " + arg + "; got " + arg
        raise xcept(spec.format(reason, expected, actual))

    @staticmethod
    def failif_not_in(needle, haystack, description=None,
                      xcept=DockerTestFail):
        """
        Convenience method for subtests to test for an expected substring
        being contained in a larger string, e.g. to look for XYZ in a
        command's stdout/stderr.

        :param needle: the string you're looking for. May be a list of
                       multiple strings separated by '|' and optional
                       whitespace, e.g. 'needle | fiddle | doo wah diddy'
        :param haystack: the actual string, e.g stdout results from a command
        :param description: description of haystack, e.g. 'stdout from foo'
        :raise DockerTestFail: if needle is not found in haystack
        """
        if description is None:
            description = 'string'
        if needle in haystack:
            return
        expect_s = "Expected string '%s'" % needle
        if '|' in needle:
            needles = [n.strip() for n in needle.split('|')]
            expect_s = "Expected strings %s" % needles
            for subneedle in needles:
                if subneedle.strip() in haystack:
                    return
        raise xcept("%s not in %s '%s'"
                    % (expect_s, description, haystack))

    @classmethod
    def failif_not_redhat(cls, xcept=DockerTestNAError):
        """
        Raises an exception if this is a non-RHEL/RHELAH System

        :param xcept: Non-default exception to raise
        :raise DockerTestNAError: By default
        """
        try:
            with open(cls.redhat_release_filepath, 'rb') as rhrel:
                if 'Enterprise' not in rhrel.read():
                    raise xcept("Test only intended for a Red Hat "
                                "Enterprise Linux System.")
        except IOError, anotherone:
            raise xcept("Is this a Red Hat Enterprise system?  "
                        "Error reading %s: %s."
                        % (cls.redhat_release_filepath,
                           str(anotherone)))

    @classmethod
    def log_x(cls, lvl, msg, *args):
        """
        Send ``msg`` & ``args`` through to logging module function with
        name ``lvl``
        """

        meth = getattr(logging, lvl)
        testname = cls.__name__
        return meth("%s%s: %s" % ("\t" * cls.n_tabs, testname, msg), *args)

    @classmethod
    def log_xn(cls, lvl, msg, *args):
        """
        Multiline-split and send msg & args through to logging module

        :param lvl: logging method name (``'debug'``, ``'info'``, etc.)
        :param msg: Message format-string
        """
        # date, loglevel, this module offset
        newline = '\n' + ' ' * cls.n_spaces + '\t' * cls.n_tabs
        newline += " " * (len(cls.__name__) + 2)    # cls name + ': '
        try:
            msg = (str(msg) % args).replace('\n', newline)
        except TypeError:
            if args is tuple():
                cls.logwarning("Following message contains format strings but "
                               "has no arguments:")
                msg = str(msg).replace('\n', newline)
            else:
                raise TypeError("Not all arguments converted during formatting"
                                ": msg='%s', args=%s" % (msg, args))
        return cls.log_x(lvl, msg)

    @classmethod
    def logdebug(cls, message, *args):
        r"""
        Log a DEBUG level message to the controlling terminal **only**

        :param message: Same as ``logging.debug()``
        :param \*args: Same as ``logging.debug()``
        """
        # Never split over multiple lines
        cls.log_x('debug', message, *args)

    @classmethod
    def loginfo(cls, message, *args):
        r"""
        Log a INFO level message to the controlling terminal **only**

        :param message: Same as ``logging.info()``
        :param \*args: Same as ``logging.info()``
        """
        cls.log_xn('info', message, *args)

    @classmethod
    def logwarning(cls, message, *args):
        r"""
        Log a WARNING level message to the controlling terminal **only**

        :param message: Same as ``logging.warning()``
        :param \*args: Same as ``logging.warning()``
        """
        cls.log_xn('warn', message, *args)

    @classmethod
    def logerror(cls, message, *args):
        r"""
        Log a ERROR level message to the controlling terminal **only**

        :param message: Same as ``logging.error()``
        :param \*args: Same as ``logging.error()``
        """
        cls.log_xn('error', message, *args)

    @classmethod
    def logtraceback(cls, name, exc_info, error_source, detail):
        r"""
        Log error to error, traceback to debug, of controlling terminal
        **only**
        """
        error_head = ("%s failed to %s\n%s\n%s" % (name, error_source,
                                                   detail.__class__.__name__,
                                                   detail))
        error_tb = traceback.format_exception(exc_info[0],
                                              exc_info[1],
                                              exc_info[2])

        error_tb_str = "".join(error_tb).strip() + "\n"
        cls.logerror(error_head)
        cls.logdebug(error_tb_str)
